Detection of Obstructive Sleep Apnea using Deep Learning on Audio Signals

Part 1: Tracheal Data

Obstructive sleep apnea (OSA) is a common breathing disorder that is rarely detected early because it appears normal but is associated with serious long-term health risks. This study analyzes a noninvasive approach for OSA detection using acoustic analysis. A deep learning model trained with tracheal audio recordings collected during PSG sessions is proposed.

The recordings were segmented into 10-s clips, transformed into Mel spectrograms, and classified using a convolutional neural network (CNN) based on the EfficientNetV2B1 architecture. The model achieved an accuracy greater than 90%, an F1 score of 91%, and an AUC-ROC of 0.96 on the test set. These results indicate the potential of audio-based deep learning systems as cost-effective and scalable tools for early detection of OSA both in clinics and remotely.

Dataset Description¶

For this project, the PSG-AUDIO dataset was selected, which contains polysomnography (PSG) signals from 212 patients. However, only 192 patients were included in this study due to they come along with a cleaner version of the annotation files available.

Each patient PSG recording includes tracheal and ambient microphone signals. Tracheal microphone signals (High-quality contact microphone placed on trachea) were selected, they contain cleaner audio compared to the ambient microphone signal.

The tracheal microphone signals:

  • originally sampled at 48 kHz in 'float64` format, a frequency that captures a broad spectrum of sounds.
  • These audio signals come along with event annotations, which labels the initial time and duration of a variety of sleep disorder cases: hypopnea, obstructive apnea, mixed apnea and central apnea.

In this project, **just obstructive apnea events were considered*, the other events were excluded and the rest of recorded time is assumed to be normal sleep.

Data Preprocessing¶

A preliminary analysis of the distribution of obstructive apnea episode durations across the dataset revealed that the majority last 10 seconds of more. A fixed segment duration of 10 seconds was chosen for all samples created, this also follows the minimum duration stated for an apnea episode defined by the American Academy of Sleep Medicine.

For Non-apnea (normal sleep) and Obstructive apnea segments:

  • Directly extracted from medical annotations by dividing each annotated event into 10-second windows.
  • For non-apnea (normal sleep) segments, 10-second windows were also extracted, but only from periods that were at least 30 seconds away from any annotated apnea episode.

This was introduced to reduce the risk of including transitional or ambiguous events that could compromise the distinction between apnea and non-apnea labels.

Signal denoising Pipeline¶

  • Harmonic-percussive source separation (HPSS): which effectively separates the stable tonal components (the hum, likely coming from nearby medical equipment, such as ventilators or refrigerators) from the percussive (respiration-related) elements.
  • Polyphase filtering: Downsample signals from 48 khz to 16 kHz. This frequency is suitable for capturing respiratory sounds, which are typically lower than 8 kHz. This approach helped reduce both storage requirements and computational demands.

Feature Extraction¶

A Mel spectrogram was created for each of the audio segments processed.

  • This representation takes audio signals from the time-domain and converts them into a 2D form of a time-frequency representation.
  • Mel spectrograms are common in biomedical audio classification.

Spectrograms were created with: 256 Mel frequency bands, 2048-point short time Fourier transform (STFT) and 512-point hop, resulting in Mel spectrograms of shape (256, 313). These all provided a reasonable balance between time resolution and frequency resolution. Next, the Mel spectrograms were converted to decibels (dB).

Such transformation effectively reduces the dynamic range and refines the representation of the features. Lastly, the spectrograms were scaled to the [0,1], consistent with practices, particularly as input normalization for deep learning.

Dataset Distribution¶

To correctly estimate the model performance, the dataset was divided into training, validation and test sets in proportion of 70%, 15% and 15% of the total dataset, but considering samples distribution per patient. This was possible with the implementation of StratifiedShuffleSplit over a metadata file with the samples distribution information per patient. Creating patient-based sets is mandatory to avoid potential data leakage and allow the model to generalize to patients that are unseen (more realistic estimate of clinical environment performance).

With this considerations, the final distribution of samples for the model training and evaluation can be created:

Group Normal Obstructive Apnea Total Sample Ratio
Train 26769 27301 54070 0.50
Validation 5856 5790 11646 0.50
Test 5402 5880 11282 0.52

In this way the class distribution becomes stable (near 50% of each class).

Finally, the samples selected were stored in float32 format as tfrecord files per patient, for storage and computational efficiently during model training/evaluation. The size of the complete dataset becomes 66.17 Gb.

Model Training and Evaluation¶

The Mel Spectrogram samples created allows us to establish this project as a binary classificaiton:

  • 1: Obstructive Sleep Apnea
  • 0: Non-apnea (Normal Sleep)

In this project, I propose a deep learning architecture based on a EfficientNetv2B1 feature extractor attached to a CNN constructed in tensorflow for binary classification of apnea versus non-apnea segments.

Total params: 6,971,573 (26.59 MB)
Trainable params: 6,900,501 (26.32 MB)
Non-trainable params: 71,072 (277.62 KB)

More precisely, during model training is included:

  • mixed-precision, which allows better utilization of GPU hardware and less consumption of memory without negatively affecting model performance.
  • cosine annealing learning rate schedule with the AdamW optimizer was implemented, initialized with a learning rate of 4e−4, to enhance the stability of convergence and provide better generalization.
  • class weights were estimated according to the samples labels in training set. To mitigate the effects of class imbalance during training.

The evaluation metrics used to estimate model performance are: accuracy, f1-score, recall, precision, auc-roc.

Import Libraries

¶

In [ ]:
import matplotlib.pyplot as plt
import seaborn as sns

import tensorflow as tf
from tensorflow.keras import layers

import os
from glob import glob
import numpy as np


import warnings

warnings.filterwarnings("ignore", category=RuntimeWarning)


try:
    resolver = tf.distribute.cluster_resolver.TPUClusterResolver(tpu='local')
    tf.config.experimental_connect_to_cluster(resolver)
    # This is the TPU initialization code that has to be at the beginning.
    tf.tpu.experimental.initialize_tpu_system(resolver)
    #`tf.distribute.experimental.TPUStrategy` is deprecated
    strategy = tf.distribute.TPUStrategy(resolver)

    #print("All devices: ", tf.config.list_logical_devices('TPU'))
except:
    strategy = tf.distribute.get_strategy()
In [2]:
from tensorflow.keras import mixed_precision

# NVIDIA GPUs support using a mix of float16 and float32,
# while TPUs and Intel CPUs support a mix of bfloat16 and float32.

if 'tpu' in strategy.cluster_resolver.__str__():
    print('Using TPU mixed precision')
    policy = mixed_precision.Policy('float32') #bfloat16') no mixing precision
    
else:
    policy = mixed_precision.Policy('mixed_float16')

mixed_precision.set_global_policy(policy)

Dataset Preparation

¶

# look for number of samples per patient
from tqdm.auto import tqdm
import pandas as pd


def analyze_tfrecord(file_path):
    _count = {i:0 for i in range(5)}
    
    dataset = tf.data.TFRecordDataset(file_path)
    dataset = dataset.map(parse_example)
    
    for record in dataset:
        _,(t_label, t_label_class) = record

        _count[t_label_class.numpy()] += 1 
    
    return _count


basedir = "/kaggle/input/apnea-audio-tfrecords/APNEA_MIC"

_data = []
for _file in tqdm( glob(os.path.join(basedir, "*")) ):
    _count = analyze_tfrecord(_file)
    _count["file"] = _file
    _data += [_count]

_df = pd.DataFrame(_data) #, columns=["File","Apnea_Count", "Normal_Count"])
_df.to_csv("_apnea_label_dist.csv", index=False)

Train, Val, Test Dataset Distribution¶

Validation per Fold must be considered

When you split by patient, it is mandatory to guarante that each subset (train/val/test) will have:

  • The same proportion of Apnea vs. Normal, or
  • The same distribution of patient types, e.g.:
    • Some patients might have a high apnea index (AHI)
    • Others might be mostly normal sleepers

This is needed to avoid:

  • Val/Test sets with unusual balance, making it harder for the model to generalize
  • Sudden drops in validation performance, like the ones you observed
In [3]:
import pandas as pd

df_dist = pd.read_csv("/kaggle/input/apnea-class-aux-files/_apnea_label_dist.csv")

# just obstructive apnea
df_dist["apnea_count"] = df_dist[['3']] #df_dist[['1','2','3','4']].sum(axis=1)
df_dist['apnea_ratio'] = df_dist['apnea_count'] / (df_dist['0'] + df_dist['apnea_count'])

# Option 1: Bin by ratio into quartiles or deciles
# Option 2: Use custom bins
bins = np.arange(0, 1.1, 0.1)
# include_lowest to avoid nan
df_dist['ratio_bin'] = pd.cut(df_dist['apnea_ratio'], bins=bins, labels=False, include_lowest=True)
#df_dist

# for version 3 only
df_dist['file'] = (df_dist['file'].str.replace("APNEA_MIC/", "")
                   .str.replace("apnea-audio-tfrecords","apnea-trach-mic")
                   .str.replace("apnea-trach-mic", "apnea-trach-mic-filtered")
                )
In [4]:
from sklearn.model_selection import StratifiedShuffleSplit

def _show_dist():
    _1 = train_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Train"})
    _2 = val_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Validation"})
    _3 = test_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Test"})

    _dist = pd.merge(_1, _2, on="ratio_bin", how="left")
    _dist = pd.merge(_dist, _3, on="ratio_bin", how="left").sort_values(by="ratio_bin")
    _dist["ratio_bin"] = _dist["ratio_bin"]/10
    return _dist.rename(columns={"ratio_bin":"Apnea Samples Group"}).round(3)
    
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.3, random_state=42)

X = df_dist['file']
y = df_dist['ratio_bin']

# First split: train vs. temp (val + test)
for train_idx, temp_idx in splitter.split(X, y):
    train_df = df_dist.iloc[train_idx]
    temp_df = df_dist.iloc[temp_idx]
    
    # Second split: val vs. test (split temp again)
    splitter_2 = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=42)

    # exclude ratio_bin group with just one element
    exclude_groups = [_bin for _bin, cond in (temp_df['ratio_bin'].value_counts()==1).items() if cond==True]
    temp_df = temp_df[temp_df['ratio_bin'].apply(lambda x: x not in exclude_groups)]
    
    X_temp = temp_df['file']
    y_temp = temp_df['ratio_bin']
    
    for val_idx, test_idx in splitter_2.split(X_temp, y_temp):
        val_df  = temp_df.iloc[val_idx]
        test_df = temp_df.iloc[test_idx]

_show_dist()
Out[4]:
Apnea Samples Group Train Validation Test
9 0.0 0.015 NaN NaN
5 0.1 0.060 0.071 0.034
2 0.2 0.164 0.143 0.172
1 0.3 0.224 0.214 0.241
0 0.4 0.239 0.250 0.241
3 0.5 0.082 0.071 0.103
6 0.6 0.052 0.071 0.034
8 0.7 0.045 0.036 0.069
4 0.8 0.075 0.071 0.069
7 0.9 0.045 0.071 0.034

The proportion of Apnea Samples per patient were balanced as much as possible.

Dataset Pipeline¶

In [5]:
# (256, 313)
N_MELS = 256
mel_time_frames = 313


def parse_example(example_proto):
    feature_description = {
        'mel_spec_mic': tf.io.FixedLenFeature([N_MELS * mel_time_frames], tf.float32),  # Flattened mel spectrogram
        'mel_spec_trach': tf.io.FixedLenFeature([N_MELS * mel_time_frames], tf.float32),
        'label':   tf.io.FixedLenFeature([], tf.float32),  # Label as float32
        'ap_type': tf.io.FixedLenFeature([], tf.int64)
    }
    
    parsed = tf.io.parse_single_example(example_proto, feature_description)
    
    # Reshape the flattened mel_spec back to the 2D spectrogram shape
    mel_spec_mic   = tf.reshape(parsed['mel_spec_mic'], [N_MELS, mel_time_frames])
    mel_spec_trach = tf.reshape(parsed['mel_spec_trach'], [N_MELS, mel_time_frames])
    
    #normalize mel_spec range -80dB to 0dB
    min_db=-80.0; max_db=0.0
    mel_spec_mic   = (mel_spec_mic - min_db) / (max_db - min_db)
    mel_spec_trach = (mel_spec_trach - min_db) / (max_db - min_db)
    
    #mel_comb = tf.stack([mel_spec_mic, mel_spec_trach], axis=-1)
    
    return (mel_spec_mic, mel_spec_trach), (tf.expand_dims(parsed['label'], axis=-1), parsed['ap_type'])
In [6]:
train_files = train_df['file'].values
valid_files = val_df['file'].values
test_files  = test_df['file'].values

def fast_filter_fn(min_std=0.006):
    """
    heuristic of pixel distribution, avoid empty Mel Spectrograms
    
    Do not hurt to set minimum 0.006
        - min std tracheal 0.006
        - min std mic      0.004
    """
    def inner_fn(image, label):
        std = tf.math.reduce_std(image)
        return std > min_std
    return inner_fn

#map_type = {0:'Normal', 1:'Hypopnea', 2:'MixedApnea', 3:'ObstructiveApnea', 4:'CentralApnea'}
def keep_only_0_or_3(mel_spec, label):
    return tf.logical_or(tf.equal(label[1], 0), tf.equal(label[1], 3))

BATCH_SIZE = 16 #32 #64#//2
GLOBAL_BATCH_SIZE = BATCH_SIZE*strategy.num_replicas_in_sync

############################################
input_data = "Tracheal" # "Tracheal", "Mic"
#input_data = "Mic"
if input_data == "Mic":
    _type = 0
else:
    _type = 1
############################################


# Load TFRecord Dataset
train_ds = (
    tf.data.Dataset.from_tensor_slices(train_files)
    .interleave(
        lambda x: tf.data.TFRecordDataset(x),
        cycle_length=tf.data.AUTOTUNE,       # Number of files to read in parallel
        num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
        deterministic=False                  # Allow non-deterministic order for speed
    )
    .map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
    .filter(keep_only_0_or_3) # just obstructive apnea
    # combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type) 
    .map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
    .filter(fast_filter_fn(min_std=0.006))
    #.cache()
    .shuffle(5000)
    .batch(GLOBAL_BATCH_SIZE)
    .prefetch(tf.data.AUTOTUNE)
)

valid_ds = (
    tf.data.Dataset.from_tensor_slices(valid_files)
    .interleave(
        lambda x: tf.data.TFRecordDataset(x),
        cycle_length=tf.data.AUTOTUNE,       # Number of files to read in parallel
        num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
        deterministic=False                  # Allow non-deterministic order for speed
    )
    .map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
    .filter(keep_only_0_or_3) # just obstructive apnea
    # combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type) 
    .map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
    .filter(fast_filter_fn(min_std=0.006))
    #.cache()
    .batch(GLOBAL_BATCH_SIZE)
    .prefetch(tf.data.AUTOTUNE)
)

test_ds = (
    tf.data.Dataset.from_tensor_slices(test_files)
    .interleave(
        lambda x: tf.data.TFRecordDataset(x),
        cycle_length=tf.data.AUTOTUNE,       # Number of files to read in parallel
        num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
        deterministic=False                  # Allow non-deterministic order for speed
    )
    .map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
    .filter(keep_only_0_or_3) # just obstructive apnea
    # combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type) 
    .map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
    .filter(fast_filter_fn(min_std=0.006))
    #.cache() # to have a deterministic test_ds
    .batch(GLOBAL_BATCH_SIZE)
    .prefetch(tf.data.AUTOTUNE)
)

print(f"\nUsing {input_data}, batches {GLOBAL_BATCH_SIZE}\n")
Using Tracheal, batches 16

In [ ]:
t_images, t_labels = valid_ds.take(1).as_numpy_iterator().next()

f,axs = plt.subplots(nrows=4, ncols=4)

for i, ax in enumerate(axs.ravel()):

    ax.imshow(t_images[i][::-1], cmap="magma")
    ax.axis("off")

plt.show()
No description has been provided for this image

Class Weights Estimation¶

In [8]:
from collections import Counter
from tqdm.auto import tqdm

#class_w = {0:0, 1:0}
#for t_images, t_labels in tqdm(train_ds):
#    _count = Counter(t_labels.numpy().reshape(-1))
#    class_w[0] += _count[0.0]
#    class_w[1] += _count[1.0]

#_total = class_w[0] +  class_w[1]
#class_w[0] = _total / (2 * class_w[0])
#class_w[1] = _total / (2 * class_w[1])
#class_w

class_w = {0: 1.0107712111460265, 1: 0.9894559335853532}

Model Training and Evaluation

¶

In [9]:
def generate_model(freeze_enc=True):
        
    base_model = tf.keras.applications.EfficientNetV2B1(include_top=False, 
                                                        weights=None, #'imagenet',
                                                        input_shape=(128*2, 313, 1),
                                                        include_preprocessing=False)
    if freeze_enc:
        base_model.trainable=False
        
    x = layers.GlobalAveragePooling2D()(base_model.output)
    x = layers.Dense(32, activation="swish")(x)
    x = layers.Dropout(rate=0.2)(x)
    x = layers.Dense(1, activation="sigmoid", dtype="float32")(x)
    
    return tf.keras.Model(inputs= base_model.input, outputs=x)

Cosine annealing learning rate schedule¶

In [10]:
import math


def warmup_cosine_lr(base_lr, total_epochs, warmup_epochs=5, min_lr=0.0):
    """
    Compute the learning rate for a given epoch using warmup + cosine annealing.
    
    Args:
        epoch (int): Current epoch number (0-based).
        base_lr (float): The initial learning rate after warmup.
        total_epochs (int): Total number of training epochs.
        warmup_epochs (int): Number of warmup epochs at the start of training.
        min_lr (float): Minimum learning rate at the end of cosine annealing.
    
    Returns:
        float: Adjusted learning rate.
    """
    def inner_function(epoch): 
        if epoch < warmup_epochs:
            # Linear warmup
            return base_lr * (epoch + 1) / warmup_epochs
        else:
            # Cosine annealing
            cosine_epochs = total_epochs - warmup_epochs
            epoch_in_cosine = epoch - warmup_epochs
            cosine_decay = 0.5 * (1 + math.cos(math.pi * epoch_in_cosine / cosine_epochs))
            return min_lr + (base_lr - min_lr) * cosine_decay

    return inner_function

# Example

lr_scheduler = warmup_cosine_lr(base_lr=4e-4, total_epochs=35, warmup_epochs=8, min_lr=1e-6)

x = list(range(35))
y = [lr_scheduler(e) for e in x]

f,ax=plt.subplots(figsize=(8,4))
ax.plot(x,y)
ax.set_title("Cosine annealing Scheduler")
ax.set_xlabel("Epoch")
ax.set_ylabel("Learning Rate")
Out[10]:
Text(0, 0.5, 'Learning Rate')
No description has been provided for this image

Model Training¶

In [11]:
with strategy.scope():

    ##########################
    model = generate_model(freeze_enc=False)
    model.load_weights("/kaggle/input/apnea-ml-weights/model_type_1.weights.h5")
    ##########################

    optimizer = tf.keras.optimizers.AdamW(learning_rate=0.0004) # clipnorm=1.0)
    #optimizer = mixed_precision.LossScaleOptimizer(optimizer, dynamic=True) # avoid underflow
    
    model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=False),
                  optimizer= optimizer,
                  metrics=['accuracy',
                           tf.keras.metrics.F1Score(threshold=0.5)])
In [ ]:
from tensorflow.keras.callbacks import EarlyStopping


lr_scheduler = warmup_cosine_lr(base_lr=3e-4, total_epochs=35, warmup_epochs=10, min_lr=1e-6)
callback_lr = tf.keras.callbacks.LearningRateScheduler(lr_scheduler)

early_stop = EarlyStopping(monitor='val_loss',
                           min_delta=0,
                           patience=5,
                           mode='auto',
                           restore_best_weights=True
                          )

history = model.fit(train_ds,
                    validation_data=valid_ds, 
                    epochs=40,
                    callbacks=[early_stop, callback_lr],
                    class_weight= class_w)
In [ ]:
# Save weights only
model.save_weights(f'model_type_{_type}.weights.h5')

#model.load_weights(f'model_type_{_type}.weights.h5')
#model.load_weights("/kaggle/input/apnea-ml-weights/model_type_1_gpu.weights.h5")
In [13]:
f,ax = plt.subplots(ncols=3, figsize=(12,4))

ax[0].set_title("Accuracy")
ax[1].set_title("F1 Score")
ax[2].set_title("Loss")

x_seg = list(range(1,len(history['accuracy'])+1))

ax[0].plot(x_seg, history['accuracy'], color="navy", label="accuracy")
ax[0].plot(x_seg, history['val_accuracy'], color="orange",  label="val_accuracy")

ax[1].plot(x_seg, history['f1_score'], color="navy",  label="f1-score")
ax[1].plot(x_seg, history['val_f1_score'], color="orange", label="val_f1-score")

ax[2].plot(x_seg, history['loss'], color="navy", label="loss")
ax[2].plot(x_seg, history['val_loss'], color="orange", label="val_loss")

for i in range(3):
    ax[i].legend()
    ax[i].set_xlabel("Epoch")
    ax[i].grid(True)
    #ax[i].set_xlim([1,11])

plt.tight_layout()
plt.show()

f.savefig("loss_trach_epoch.pdf", dpi=300, bbox_inches='tight')
No description has been provided for this image

Results Analysis

¶

Mel Spectrogram Apnea type: Obstructive Apnea

Tracheal Data using EfficientNetV2B1 trained over GPU.

Metrics Analysis¶

In [15]:
# test_ds other can change if called multiple times, due to interleave, prefetch, a deterministic test_ds must use .cache()

y_true = []
y_pred_proba = []

for batch_x, batch_y in tqdm( test_ds ):
    
    _preds = model.predict(batch_x, verbose=0)
    y_pred_proba.extend(_preds.flatten())  # Binary threshold
    y_true.extend(batch_y.numpy().flatten())
0it [00:00, ?it/s]
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1757266699.632271      96 service.cc:148] XLA service 0x7cf4c40103e0 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:
I0000 00:00:1757266699.632960      96 service.cc:156]   StreamExecutor device (0): Tesla P100-PCIE-16GB, Compute Capability 6.0
I0000 00:00:1757266700.607146      96 cuda_dnn.cc:529] Loaded cuDNN version 90300
I0000 00:00:1757266705.664046      96 device_compiler.h:188] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.

Threshold Tuning (Precision, Recall, F1 vs Threshold)¶

In [16]:
import numpy as np
from sklearn.metrics import precision_recall_fscore_support

thresholds = np.linspace(0.0, 1.0, 101)
precision, recall, f1 = [], [], []

for t in thresholds:
    preds = (y_pred_proba >= t).astype(int)
    p, r, f, _ = precision_recall_fscore_support(y_true, preds, average='binary', zero_division=0)
    precision.append(p)
    recall.append(r)
    f1.append(f)

f,ax = plt.subplots(figsize=(6, 3))
ax.plot(thresholds, precision, label='Precision')
ax.plot(thresholds, recall, label='Recall')
ax.plot(thresholds, f1, label='F1 Score')

ax.axvline(0.3, color='navy', linestyle='--', label='threshold=0.3')

ax.axvline(0.5, color='gray', linestyle='--', label='Default threshold 0.5')
ax.set_xlabel('Threshold')
ax.set_ylabel('Score')
ax.set_title('Precision, Recall, and F1 Score vs Threshold')

plt.legend(loc='lower right')
plt.grid(lw=0.25)
plt.show()

#f.savefig("tr_metrics.pdf", dpi=300, bbox_inches='tight')
No description has been provided for this image

In the graphic is possible to see that F1-score is maximized around a threshold of 0.3 without a representative loss in general precision.

In [17]:
tr = 0.3 #0.5
y_pred = (np.array(y_pred_proba) >= tr).astype(int)
In [18]:
from sklearn.metrics import confusion_matrix

# True and predicted labels
cm = confusion_matrix(y_true, y_pred)

# Convert to percentage (row-wise or total)
cm_percent = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] * 100

# Plot
f, ax = plt.subplots(figsize=(4, 3))
sns.heatmap(cm_percent, annot=True, fmt=".1f", cmap="Blues", xticklabels=['Normal', 'OSA'], yticklabels=['Normal', 'OSA'])
ax.set_xlabel("Predicted")
ax.set_ylabel("True")
ax.set_title("Confusion Matrix (% per class)")
plt.show()

f.savefig("confusion_matrix.pdf", dpi=300, bbox_inches='tight')
No description has been provided for this image
In [21]:
from sklearn.metrics import roc_curve, roc_auc_score, RocCurveDisplay

fpr, tpr, thresholds = roc_curve(y_true, y_pred_proba)
auc = roc_auc_score(y_true, y_pred_proba)

plt.figure(figsize=(7,4))
RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=auc, estimator_name='Model').plot()
plt.title(f"ROC Curve (AUC = {auc:.2f})")
plt.grid(True)
plt.show()
<Figure size 700x400 with 0 Axes>
No description has been provided for this image
In [22]:
# Step 4: Classification report
from sklearn.metrics import classification_report

print(classification_report(y_true, y_pred, target_names=["No Apnea", "Apnea"]))
              precision    recall  f1-score   support

    No Apnea       0.90      0.91      0.90      5402
       Apnea       0.91      0.91      0.91      5880

    accuracy                           0.91     11282
   macro avg       0.91      0.91      0.91     11282
weighted avg       0.91      0.91      0.91     11282

The evaluation results reflect the model’s performance on test set:

  • Overall accuracy of 91%
  • With classification thresholds of 0.3: F1-score of 91%.

The absence of significant differences in Recall between apnea and non-apnea classes suggests that the model is consistently capable of detecting apnea events while correctly identifying normal sleep, supporting its reliability for practical use.

Good Examples¶

In [ ]:
import librosa

def plot_examples(batch_x, batch_y, good_ex=False, j=0):
    preds = (model.predict(batch_x) > 0.5).astype("int").flatten()
    batch_y = batch_y.numpy().reshape(-1).astype("int")
    index_l = np.where(preds ^ batch_y == (0 if good_ex else 1) )[0] # xor ^ comparison
    
    if len(index_l)==0: return None
    
    nplots = len(index_l) + ((4 - len(index_l)%4) if len(index_l)%4 !=0 else 0)
    nrows = nplots//4
    
    #f,ax=plt.subplots(nrows=nrows, ncols=4, figsize=(10, 2*nrows))
    f,ax=plt.subplots(nrows=2, ncols=2, figsize=(6, 4))
    
    for i, _ax in enumerate(ax.ravel()):
        if i<len(index_l):
            _ax.set_title(f"Real {batch_y[index_l[i]]}, Predicted {preds[index_l[i]]}")
            librosa.display.specshow(batch_x[index_l[i]].numpy(), x_axis='time', y_axis='mel', ax=_ax, sr=16000)
            #_ax.imshow(batch_x[index_l[i]][::-1], cmap="magma")
        else:
            _ax.set_visible(False)
    f.tight_layout()
    f.savefig(("good_ex" if good_ex else "bad_ex") + f"{j}.pdf", dpi=300, bbox_inches='tight')
In [28]:
for i, (batch_x, batch_y) in enumerate(test_ds):

    plot_examples(batch_x, batch_y, good_ex=True)
    if i>0: break
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 50ms/step
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 51ms/step
No description has been provided for this image
No description has been provided for this image

Bad examples analysis¶

In [26]:
for i, (batch_x, batch_y) in enumerate(test_ds):

    plot_examples(batch_x, batch_y, good_ex=False, j=i)
    #plot_examples(batch_x, batch_y, good_ex=True, j=i)
    if i>0: break
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 51ms/step
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 53ms/step
No description has been provided for this image
No description has been provided for this image

Certain apnea events were misclassified as normal sleep, likely from subtle or unclear breathing patterns.

These examples suggest the need for future work focused on challenging cases, potentially through advanced noise reduction techniques like denoising autoencoders and the inclusion of data from non-clinical environments to improve generalization.

In general, the results support an effective approach to OSA detection and breathing analysis.